昨天我們完成了收藏功能與收藏頁,今天把「心得」做出來。雖然只是前端表單,但好的驗證與回饋能讓體驗差很多,也為之後的心得牆打底。
review.html
(頁面骨架+表單)在專案根目錄建立 review.html
,貼上以下內容:
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>DramaWeb|心得撰寫</title>
<link rel="stylesheet" href="style.css"/>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<!-- 跳到主內容(a11y) -->
<a class="skip-link" href="#main">跳到主內容</a>
<header>
<button class="menu" aria-label="開啟選單"><span>☰</span></button>
<h1>DramaWeb</h1>
<nav>
<ul>
<li><a href="front.html">首頁</a></li>
<li><a href="type.html">類型篩選:</a></li>
<li><a href="favorite.html">收藏清單:</a></li>
<li><a href="review.html" aria-current="page">心得牆:</a></li>
</ul>
</nav>
</header>
<main id="main">
<section class="news">
<h2>撰寫你的觀後心得</h2>
<p>選擇劇名、填入評分與心得,按下送出後會即時顯示在下方列表(Day 12 會加入永久保存)。</p>
</section>
<!-- 心得表單 -->
<section class="review-form">
<form id="reviewForm" novalidate>
<!-- 劇名(支援 datalist) -->
<div class="field">
<label for="titleInput">劇名 <span class="req">*</span></label>
<input id="titleInput" name="title" list="showList" placeholder="請輸入或選擇劇名" required>
<datalist id="showList"><!-- 由 JS 依 SHOWS 填入 --></datalist>
<p class="error" id="errTitle" role="alert" hidden>請輸入 2–40 字的劇名。</p>
</div>
<!-- 評分(1–10) -->
<div class="field">
<label for="ratingInput">評分(1–10)<span class="req">*</span></label>
<input id="ratingInput" name="rating" type="number" min="1" max="10" step="0.1" placeholder="例如 8.5" required>
<p class="error" id="errRating" role="alert" hidden>請輸入 1–10 的數字。</p>
</div>
<!-- 暱稱(選填) -->
<div class="field">
<label for="nameInput">暱稱(選填)</label>
<input id="nameInput" name="name" maxlength="20" placeholder="未填則顯示為:匿名">
</div>
<!-- 心得內容 -->
<div class="field">
<label for="contentInput">心得內容 <span class="req">*</span></label>
<textarea id="contentInput" name="content" rows="6" placeholder="至少 30 個字,分享你的觀影感受…" required></textarea>
<div class="hint">
<span>字數:<span id="contentCount">0</span></span>
</div>
<p class="error" id="errContent" role="alert" hidden>心得至少 30 字,最多 1000 字。</p>
</div>
<div class="actions">
<button id="btnSubmitReview" class="btn" type="submit">送出心得(Ctrl/⌘+Enter)</button>
<button id="btnResetReview" class="btn btn-clear" type="button">清空表單</button>
</div>
<p class="form-msg" id="formMsg" aria-live="polite" hidden>已送出!</p>
</form>
</section>
<!-- 心得列表(Day 11 先存在記憶體,Day 12 才存 LocalStorage) -->
<section class="review-list">
<h2>最新心得</h2>
<div id="reviewList" class="reviews">
<div class="empty">目前還沒有心得,快成為第一個分享的人吧!</div>
</div>
</section>
</main>
<footer>
<p>DramaWeb © 2025 All Rights Reserved.</p>
</footer>
<!-- 提供 SHOWS:載入 Day10 的 app.js -->
<script src="js/app.js"></script>
<!-- 今日腳本:表單驗證與渲染(記憶體) -->
<script src="js/reviews.js"></script>
</body>
</html>
style.css
末尾加入表單樣式(沿用 Day 5 的設計變數)
/* Day 11:心得表單樣式 */
.review-form, .review-list{
margin: 18px 0; padding: 18px 16px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.field{ display: grid; gap: 6px; margin-bottom: 12px; }
.field label{ font-weight: 600; }
.field .req{ color: #ef4444; margin-left: 2px; }
.field input, .field textarea{
border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; font-size: 1rem; background: #fff; color: var(--text);
}
.field input:focus, .field textarea:focus{ outline: 3px solid rgba(124,58,237,.2); outline-offset: 1px; }
.field input[aria-invalid="true"], .field textarea[aria-invalid="true"]{ border-color: #ef4444; background: #fff7f7; }
.error{ color: #b91c1c; font-size: .92rem; margin: 2px 0 0; }
.hint{ color: var(--muted); font-size: .9rem; }
.actions{ display: flex; gap: 10px; margin-top: 6px; }
.form-msg{ margin-top: 8px; color: #065f46; background: #ecfdf5; border: 1px solid #a7f3d0; padding: 8px 10px; border-radius: 8px; }
.reviews{ display: grid; gap: 12px; }
.review-item{
border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--surface);
}
.review-head{ display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.review-title{ margin: 0; font-weight: 700; }
.review-meta{ color: var(--muted); font-size: .92rem; }
.review-content{ margin: 6px 0 0; white-space: pre-wrap; word-break: break-word; }
.review-actions{ margin-top: 8px; display:flex; gap:8px; }
.btn-del{ border: 1px solid #ef4444; color: #ef4444; background: #fff; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
.btn-del:hover{ background: #fef2f2; }
/* skip link(若尚未加入 Day7 的可複用這段) */
.skip-link {
position: absolute; left: -9999px; top: 0;
background: #111; color: #fff; padding: 8px 12px; border-radius: 8px;
}
.skip-link:focus { left: 12px; top: 12px; z-index: 1000; }
js/reviews.js
(驗證+渲染到列表;只存在記憶體)在 js/
底下建立 reviews.js
,貼上:
// js/reviews.js — Day 11:心得表單(驗證+記憶體渲染)
(function(){
// 來源於 app.js(Day10 已曝露)
const SHOWS = window.DRAMA_SHOWS || [];
const LABELS = window.DRAMA_LABELS || {};
const $form = $('#reviewForm');
const $title = $('#titleInput');
const $rating = $('#ratingInput');
const $name = $('#nameInput');
const $content = $('#contentInput');
const $count = $('#contentCount');
const $msg = $('#formMsg');
const $list = $('#reviewList');
const $reset = $('#btnResetReview');
// 建立 datalist(劇名下拉提示)
function buildDatalist(){
const $dl = $('#showList').empty();
SHOWS.forEach(s => $dl.append(`<option value="${s.title}"></option>`));
}
// 內存中的心得列表
const REVIEWS = []; // Day 12 會改用 LocalStorage
// 簡易跳脫(避免 XSS)
function esc(str){
return String(str)
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
function render(){
if (!REVIEWS.length){
$list.html(`<div class="empty">目前還沒有心得,快成為第一個分享的人吧!</div>`);
return;
}
const html = REVIEWS.slice().reverse().map(item => {
const tags = (item.genres || []).map(g => LABELS[g] || g).join(' / ');
const metaRight = tags ? ` · ${tags}` : '';
return `
<article class="review-item" data-id="${item.id}">
<div class="review-head">
<h3 class="review-title">${esc(item.title)}</h3>
<span class="review-meta">⭐ ${item.rating} · ${esc(item.name || '匿名')}${metaRight}</span>
</div>
<p class="review-content">${esc(item.content)}</p>
<div class="review-actions">
<button class="btn-del" type="button">刪除</button>
</div>
</article>
`;
}).join('');
$list.html(html);
}
// 即時字數
$content.on('input', function(){
$count.text($(this).val().length);
});
// 欄位驗證
function setError($el, $errEl, cond, msg){
if (cond){ // 有錯
$el.attr('aria-invalid', 'true');
if (msg) $errEl.text(msg);
$errEl.prop('hidden', false);
} else {
$el.attr('aria-invalid', 'false');
$errEl.prop('hidden', true);
}
return !cond;
}
function validate(){
const titleVal = $title.val().trim();
const ratingVal = parseFloat($rating.val());
const contentVal = $content.val().trim();
const okTitle = setError($title, $('#errTitle'), !(titleVal.length >= 2 && titleVal.length <= 40));
const okRating = setError($rating, $('#errRating'), !(Number.isFinite(ratingVal) && ratingVal >= 1 && ratingVal <= 10));
const okContent = setError($content, $('#errContent'), !(contentVal.length >= 30 && contentVal.length <= 1000));
// 聚焦第一個錯誤
if (!okTitle) { $title.focus(); return false; }
if (!okRating) { $rating.focus(); return false; }
if (!okContent) { $content.focus(); return false; }
return true;
}
// 找出對應的劇(若有)
function findShowByTitle(title){
return SHOWS.find(s => s.title === title);
}
// 送出
$form.on('submit', function(e){
e.preventDefault();
if (!validate()) return;
const titleVal = $title.val().trim();
const ratingVal = parseFloat($rating.val());
const nameVal = $name.val().trim() || '匿名';
const contentVal = $content.val().trim();
const match = findShowByTitle(titleVal);
const review = {
id: 'rv_' + Date.now(),
title: titleVal,
rating: ratingVal.toFixed(1),
name: nameVal,
content: contentVal,
genres: match ? match.genres : []
};
REVIEWS.push(review);
// 重設並提示
$form[0].reset();
$count.text('0');
$msg.text('已送出!').prop('hidden', false);
setTimeout(() => $msg.prop('hidden', true), 1500);
render();
});
// Ctrl/⌘ + Enter 快速送出
$(document).on('keydown', function(e){
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter'){
$('#btnSubmitReview').click();
}
});
// 清空表單
$reset.on('click', function(){
$form[0].reset();
$count.text('0');
// 清除錯誤狀態
['#titleInput','#ratingInput','#contentInput'].forEach(sel => $(sel).attr('aria-invalid','false'));
$('.error').prop('hidden', true);
});
// 刪除單筆(Day 11 先從記憶體移除)
$(document).on('click', '.review-item .btn-del', function(){
const id = $(this).closest('.review-item').data('id');
const idx = REVIEWS.findIndex(r => r.id === id);
if (idx >= 0){ REVIEWS.splice(idx, 1); render(); }
});
// 初始化
$(function(){
buildDatalist();
render();
});
})();